💡 이전 포스트에서 아래와 같은 피드백을 받았다. 모르는 내용이 많아 리서치 및 정리해봄
feedback: runner.os와 runner.arch까지 hash key에 넣어야 안전할 것 같네요 (pip site packages는 cpu arch를 타므로) 그리고 site packages를 캐시하면 installation time에 실행되어야 하는 script 들은 이후에 skip 될 수 있어서 side effect가 발생할 수 있어서, 보통은 pip cache dir를 캐시하고 이걸 재사용 하는 편입니다. (musl / glibc와 같은 호스트 의존적인 이슈들도 발생할 수 있음)

runner.arch 란?

runner.arch 는 Github actions runner 의 CPU 아키텍처를 의미하는 변수로 ${{ runner.arch }} 처럼 사용된다.

- X64 - Intel/AMD 64비트 (x86_64)
- X86 - Intel/AMD 32비트
- ARM - ARM 32비트
- ARM64 - ARM 64비트 (Apple M1/M2, AWS Graviton 등)

실제 yaml 파일에서는 아래 처럼 선언할 수 있다. runner 서버의 os 가 ubuntu 면 runner 의 cpu architecture 는 X64 가 된다.

runs-on: ubuntu-latest  # github 에서 제공 -> 항상 X64 (cpu arch)
// -> runner.arch = "X64"

만약 runner 가 self-hosted 서버라면 해당 서버가 어떤 아키텍처를 사용하는지에 따라 arch 가 결정된다. self hosted 서버의 경우 아래처럼 옵셔널하게 label 을 지정하여 나타낼 수도 있다.

runs-on: [self-hosted, linux, ARM64]  # ARM 서버만 사용
// -> runner.arch = "ARM64"

참고로 ${{ runner.arch }} 가 될 수 있는 값은 아래와 같다.

  • X64 - Intel/AMD 64비트 (x86_64)
  • X86 - Intel/AMD 32비트
  • ARM - ARM 32비트
  • ARM64 - ARM 64비트 (Apple M1/M2, AWS Graviton 등)

참고) AMD, ARM 은 각각 회사 이름이자 아키텍처 이름이다. (Advanced Micro Devices, Advanced RISC Machines)


CPU arch 를 신경써야 하는 이유

numpy 는 순수 python 이 아니라 C 로 작성된 부분이 많은 라이브러리로 아래 구조처럼 C 로 컴파일된 바이너리 파일을 가지고 있다.

numpy/
├── __init__.py           # Python 코드
├── core/
│   ├── _multiarray_umath.so  # C로 컴파일된 바이너리
│   ├── _multiarray_tests.so  # C로 컴파일된 바이너리
│   └── ...
└── linalg/
    ├── _umath_linalg.so      # C로 컴파일된 바이너리
    └── ...
    
    
> so stands for shared object = Linux 의 동적 라이브러리
> `.so` 파일은 기계어를 의미한다.

그리고 numpy 를 사용하는 파이썬 코드의 동작 방식을 간단하게 나타내면 아래와 같다.

 # __init__.py
import numpy as np
arr = np.array([1, 2, 3])

위 파이썬 코드가 실행되면 내부적으로:

numpy/__init__.py (Python)
numpy/core/_multiarray_umath.so (기계어)
CPU 명령어 실행

문제는 동일한 numpy 라이브러리 코드라 하더라도 기계어는 CPU 아키텍처 마다 별도로 존재한다! 기계어는 CPU 가 직접 실행하는 명령어기 때문에 CPU 아키텍처에 종속적이다. 개발자가 작성하는 코드는 보통 high level(e.g Python) 언어이기 때문에 cpu 아키텍처와 독립적이며 보통 이를 신경쓰지 않고 코드를 작성한다. 하지만 numpy 처럼 성능을 위해 C 로 작성된 확장 모듈(.so)은 컴파일된 기계어를 포함하므로 x86_64에서 빌드된 바이너리를 ARM64에서 실행할 수 없다.

따라서 캐시 키에 runner.arch를 포함해 아키텍처별로 바이너리를 구분해야 한다.


site-packages 를 그대로 캐싱하면 위험한 이유

CI 시간을 단축하기 위해 pip 다운로드 캐시 뿐만 아니라 설치된 패키지(site-packages)까지 캐싱하면 어떤 문제가 생길까?

runner 서버의 아키텍처가 x86_64 일 때 pip install 을 통해 설치한 numpy 를 캐싱했다고 가정해보자. 캐싱된 패키지의 기계어는 오직 x86_64 CPU 에서만 실행된다.

개발자가 모르는 사이에 데브옵스 팀에서 runner 서버의 아키텍처만 ARM64 (혹은 그 외)로 변경하면 어떻게 될까? 이미 x86_64 용으로 컴파일된 기계어(.so)가 캐시 히트 되지만 ARM64 CPU 는 해당 코드(기계어)를 실행할 수 없고 Exec format error! 같은 에러 메시지만 보게 된다.

┌─────────────────────────────────────┐
│ 1. pip install numpy (x86_64 서버)   │
└────────────┬────────────────────────┘
┌─────────────────────────────────────┐
│ 2. numpy wheel 다운로드/컴파일          │
│    → _multiarray_umath.so (x86_64)  │
└────────────┬────────────────────────┘
┌─────────────────────────────────────┐
│ 3. site-packages에 설치               │ 
│    /site-packages/numpy/            │
│    ├── __init__.py (Python)         │
│    └── core/_multiarray_umath.so    │
│         (x86_64 기계어) ⭐            │
└────────────┬────────────────────────┘
┌─────────────────────────────────────┐
│ 4. site-packages 캐시 저장             │
│    key: linux-py39-abc123           │
└────────────┬────────────────────────┘
┌─────────────────────────────────────┐
│ 5. 새 runner (ARM64) 시작             │
│    캐시 복원 (같은 키)                  │
└────────────┬────────────────────────┘
┌─────────────────────────────────────┐
│ 6. import numpy 실행                 │
│    → _multiarray_umath.so 로드 시도   │
│    → x86_64 명령어를 ARM64에서?│    💥 Exec format error!└─────────────────────────────────────┘

runner.arch 까지 고려한 안전한 캐싱 전략

1: 캐시 키에 아키텍처 포함

아래 처럼 아키텍처 별로 캐시를 분리하면 runner.arch 가 변경될 경우 캐시 미스가 발생하기 때문에 패키지를 재설치해 변경된 아키텍처에 맞게 기계어를 컴파일 한다.

- name: Setup pip packages cache
  uses: actions/cache@v3
  with:
    path: /usr/local/lib/python2.7/site-packages
    key: ${{ runner.os }}-${{ runner.arch }}-py27-packages-v2-${{ hashFiles('requirements.txt') }}
    # 예: linux-X64-py27-packages-v2-abc123
    #     linux-ARM64-py27-packages-v2-abc123

2: pip 다운로드 캐시만 사용 (안전)

캐시 키에 아키텍처를 포함하여 아키텍처 변경 시 발생하는 문제를 예방할 수 있지만, 사실 가장 안전한 방법은 pip 다운로드 캐시만 사용하는 것이다.

- name: Setup pip download cache
  uses: actions/cache@v3
  with:
    path: ${{ github.workspace }}/.cache/pip
    key: ${{ runner.os }}-${{ runner.arch }}-pip-v4-${{ hashFiles('requirements.txt') }}

위 처럼 개선 시 여전히 pip 다운로드는 캐시되지만 매번 패키지 설치 단계는 실행되어 ci 시간은 더 걸릴 수 있다. 하지만 피드백 내용처럼 setup.py 같은 설치 스크립트(post-install)가 존재할 경우 캐싱된 라이브러리에서는 이게 제대로 동작하지 않을 수도 있으므로, 매번 패키지 설치를 진행하는 것이 오히려 확실히 안전성을 보장할 수 있는 방법이다.

img.png

혹시나 해서 찾아봤는데 numpy 라이브러리만 봐도 꽤나 많은 post-install setup.py 파일이 존재한다. 그러니 site-packages 를 그대로 캐싱하는 건 사이드 이펙트 발생 여지가 많으므로 pip download 만 캐싱하도록 변경하자.